From 1035eb815f5a4996d8f546aa4b85da29ccea5d73 Mon Sep 17 00:00:00 2001
From: Owen Jacobson <owen@grimoire.ca>
Date: Fri, 2 May 2025 01:24:10 -0400
Subject: Send messages through an outbox, rather than sending them to the API
 directly from the UI.

This primarily serves to free up the message input immediately, so that the user can start drafting their next message right away. The wait while a message is being sent is actively disruptive when using Pilcrow on a server with noticable latency (hi.grimoire.ca has around 700ms), and this largely alleviates it.

Unsent messages can be lost if the client is closed or deactivated before they make it to the head of the queue.
---
 ui/routes/(app)/ch/[channel]/+page.svelte | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

(limited to 'ui/routes/(app)/ch/[channel]/+page.svelte')

diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte
index c8507cc..9506b67 100644
--- a/ui/routes/(app)/ch/[channel]/+page.svelte
+++ b/ui/routes/(app)/ch/[channel]/+page.svelte
@@ -7,7 +7,7 @@
   import * as api from '$lib/apiServer';
 
   const { data } = $props();
-  const { session } = data;
+  const { session, outbox } = data;
   let activeChannel;
 
   const channel = $derived(page.params.channel);
@@ -65,7 +65,7 @@
   }
 
   async function sendMessage(message) {
-    await api.postToChannel(channel, message);
+    outbox.send(channel, message);
   }
 
   async function deleteMessage(id) {
-- 
cgit v1.2.3


From d11ff421de581a835f1645b22d1f7f4304640b0c Mon Sep 17 00:00:00 2001
From: Owen Jacobson <owen@grimoire.ca>
Date: Mon, 5 May 2025 19:51:58 -0400
Subject: Use the outbox for more than just message sends.

A handful of operations are "synchronized" - that is, the server sends back information about them when the client asks to perform them, but notifies _all_ clients of completion through the event stream. As of this writing, these operations include sending and deleting messages, creating and deleting channels, and anything that creates new users.

We can use the outbox for most of these. I've opted _not_ to use the outbox for creating users, as that often takes place when the client is not connected to the event stream (and can't be) and so cannot discover that the operation has been completed after it is sent.

Outboxed tasks are objects, not closures, even though they behave in closure-like ways (`send()` carries out what amounts to "attempt this operation until it succeeds"). This is deliberate; I want the properties of incomplete tasks to be inspectable down the line, so that we can put them in the UI. If they're mere closures, we can't do that.

It is deliberate that `outbox.postToChannel` et al do not return promises. I contemplated it, but it will interact weirdly with outbox serialization, when we get to that. Rather than relying on a promise to determine when an operation has completed, clients of `Outbox` should monitor its properties. (We can add additional view properties to make that easier.)
---
 ui/lib/outbox.svelte.js                   | 46 +++++++++++++++++++++++++++----
 ui/routes/(app)/+layout.svelte            |  2 +-
 ui/routes/(app)/ch/[channel]/+page.svelte |  5 ++--
 3 files changed, 44 insertions(+), 9 deletions(-)

(limited to 'ui/routes/(app)/ch/[channel]/+page.svelte')

diff --git a/ui/lib/outbox.svelte.js b/ui/lib/outbox.svelte.js
index 0681f29..de7f80e 100644
--- a/ui/lib/outbox.svelte.js
+++ b/ui/lib/outbox.svelte.js
@@ -1,12 +1,36 @@
 import * as api from './apiServer.js';
 import * as md from './markdown.js';
 
-class Message {
+class PostToChannel {
   constructor(channel, body) {
     this.channel = channel;
     this.body = body;
     this.renderedBody = md.render(body);
   }
+
+  async send() {
+    return await api.retry(() => api.postToChannel(this.channel, this.body));
+  }
+}
+
+class DeleteMessage {
+  constructor(messageId) {
+    this.messageId = messageId;
+  }
+
+  async send() {
+    return await api.retry(() => api.deleteMessage(this.messageId));
+  }
+}
+
+class CreateChannel {
+  constructor(name) {
+    this.name = name;
+  }
+
+  async send() {
+    return await api.retry(() => api.createChannel(this.name));
+  }
 }
 
 export class Outbox {
@@ -20,11 +44,23 @@ export class Outbox {
     this.pending = pending;
   }
 
-  send(channel, body) {
-    this.pending.push(new Message(channel, body));
+  enqueue(operation) {
+    this.pending.push(operation);
     this.start();
   }
 
+  createChannel(name) {
+    this.enqueue(new CreateChannel(name));
+  }
+
+  postToChannel(channel, body) {
+    this.enqueue(new PostToChannel(channel, body));
+  }
+
+  deleteMessage(messageId) {
+    this.enqueue(new DeleteMessage(messageId));
+  }
+
   start() {
     if (this.sending) {
       return;
@@ -38,9 +74,9 @@ export class Outbox {
 
   async drain() {
     while (this.pending.length > 0) {
-      const { channel, body } = this.pending[0];
+      const operation = this.pending[0];
 
-      await api.retry(() => api.postToChannel(channel, body));
+      await operation.send();
       this.pending.shift();
     }
   }
diff --git a/ui/routes/(app)/+layout.svelte b/ui/routes/(app)/+layout.svelte
index 116767d..a4ae442 100644
--- a/ui/routes/(app)/+layout.svelte
+++ b/ui/routes/(app)/+layout.svelte
@@ -87,7 +87,7 @@
   });
 
   async function createChannel(name) {
-    await api.createChannel(name);
+    outbox.createChannel(name);
   }
 
   function onbeforeunload(event) {
diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte
index 9506b67..abec00d 100644
--- a/ui/routes/(app)/ch/[channel]/+page.svelte
+++ b/ui/routes/(app)/ch/[channel]/+page.svelte
@@ -4,7 +4,6 @@
   import ActiveChannel from '$lib/components/ActiveChannel.svelte';
   import MessageInput from '$lib/components/MessageInput.svelte';
   import { runs } from '$lib/runs.js';
-  import * as api from '$lib/apiServer';
 
   const { data } = $props();
   const { session, outbox } = data;
@@ -65,11 +64,11 @@
   }
 
   async function sendMessage(message) {
-    outbox.send(channel, message);
+    outbox.postToChannel(channel, message);
   }
 
   async function deleteMessage(id) {
-    await api.deleteMessage(id);
+    outbox.deleteMessage(id);
   }
 </script>
 
-- 
cgit v1.2.3


From adc1aef3dbc6b9d5f08f215d527a37e7cc59ddd9 Mon Sep 17 00:00:00 2001
From: Owen Jacobson <owen@grimoire.ca>
Date: Tue, 6 May 2025 00:51:06 -0400
Subject: Un-nest `Message` from `MessageRun`.

A `MessageRun` is a visual container with a specific layout - bordered, with a drop shadow, with a name badge on the top-left, which is either positioned to the left (`other-message`) or right (`own-message`). It is content-agnostic.

This facilitates putting things besides live messages inside of a message run. As a side effect, this gets rid of ActiveChannel; most of what it was doing makes more sense living in the channel view's `+page.svelte`.
---
 ui/lib/components/ActiveChannel.svelte    | 15 ---------------
 ui/lib/components/MessageRun.svelte       | 10 +++-------
 ui/routes/(app)/ch/[channel]/+page.svelte | 23 +++++++++++++++++++++--
 3 files changed, 24 insertions(+), 24 deletions(-)
 delete mode 100644 ui/lib/components/ActiveChannel.svelte

(limited to 'ui/routes/(app)/ch/[channel]/+page.svelte')

diff --git a/ui/lib/components/ActiveChannel.svelte b/ui/lib/components/ActiveChannel.svelte
deleted file mode 100644
index 30b8385..0000000
--- a/ui/lib/components/ActiveChannel.svelte
+++ /dev/null
@@ -1,15 +0,0 @@
-<script>
-  import MessageRun from './MessageRun.svelte';
-
-  let { messageRuns, deleteMessage = async (id) => {} } = $props();
-
-  $effect(() => {
-    // This is just to force it to track messageRuns.
-    const _ = messageRuns;
-    document.querySelector('.message-run:last-child .message:last-child').scrollIntoView();
-  });
-</script>
-
-{#each messageRuns as { sender, ownMessage, messages }}
-  <MessageRun {sender} {ownMessage} {messages} {deleteMessage} />
-{/each}
diff --git a/ui/lib/components/MessageRun.svelte b/ui/lib/components/MessageRun.svelte
index f1facd3..f6e7ee1 100644
--- a/ui/lib/components/MessageRun.svelte
+++ b/ui/lib/components/MessageRun.svelte
@@ -1,14 +1,10 @@
 <script>
-  import Message from '$lib/components/Message.svelte';
-
-  let { sender, messages, ownMessage, deleteMessage = async (id) => {} } = $props();
+  let { sender, children, class: cssClass } = $props();
 </script>
 
-<div class="message-run" class:own-message={ownMessage} class:other-message={!ownMessage}>
+<div class={['message-run', cssClass]}>
   <span class="username">
     @{sender}:
   </span>
-  {#each messages as message}
-    <Message {...message} editable={ownMessage} {deleteMessage} />
-  {/each}
+  {@render children?.()}
 </div>
diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte
index abec00d..ccf455c 100644
--- a/ui/routes/(app)/ch/[channel]/+page.svelte
+++ b/ui/routes/(app)/ch/[channel]/+page.svelte
@@ -1,8 +1,9 @@
 <script>
   import { DateTime } from 'luxon';
   import { page } from '$app/state';
-  import ActiveChannel from '$lib/components/ActiveChannel.svelte';
   import MessageInput from '$lib/components/MessageInput.svelte';
+  import MessageRun from '$lib/components/MessageRun.svelte';
+  import Message from '$lib/components/Message.svelte';
   import { runs } from '$lib/runs.js';
 
   const { data } = $props();
@@ -50,6 +51,12 @@
     setLastRead();
   });
 
+  $effect(() => {
+    // This is just to force it to track messageRuns.
+    const _ = messageRuns;
+    document.querySelector('.message-run:last-child .message:last-child')?.scrollIntoView();
+  });
+
   function handleKeydown(event) {
     if (event.key === 'Escape') {
       setLastRead(); // TODO: pass in "last message DT"?
@@ -75,7 +82,19 @@
 <svelte:window onkeydown={handleKeydown} />
 
 <div class="active-channel" {onscroll} bind:this={activeChannel}>
-  <ActiveChannel {messageRuns} {deleteMessage} />
+  {#each messageRuns as { sender, ownMessage, messages }}
+    <MessageRun
+      {sender}
+      class={{
+        ['own-message']: ownMessage,
+        ['other-message']: !ownMessage
+      }}
+    >
+      {#each messages as message}
+        <Message {...message} editable={ownMessage} {deleteMessage} />
+      {/each}
+    </MessageRun>
+  {/each}
 </div>
 <div class="create-message">
   <MessageInput {sendMessage} />
-- 
cgit v1.2.3


From e4273ffd945f16d6f74e9c64431808ea36148880 Mon Sep 17 00:00:00 2001
From: Owen Jacobson <owen@grimoire.ca>
Date: Tue, 6 May 2025 01:07:54 -0400
Subject: Render "ghost" messages for unsent messages.

There is a subtle race conditon in this code, which is likely not fixable without a protocol change:

* Ghost messages can disappear before their "real" message replacement shows up, if the client finishes sending (i.e., receives an HTTP response on the POST) before the server delivers the real message.
* Ghost messages can be duplicated briefly, if the client receives the real message before the client finishes sending.

Both happen in practice; we make no ordering guarantees between requests.

To aviod this, we'd to give clients a way to correlate pending sends with received messages. This would require fundamentally the same capabilities, like per-operation nonces, that preventing duplicate operations will require.
---
 ui/lib/components/Message.svelte          | 11 +++++++++--
 ui/lib/outbox.svelte.js                   | 16 ++++++++++++++++
 ui/lib/runs.js                            | 10 +++++++++-
 ui/lib/state/remote/messages.svelte.js    |  2 +-
 ui/routes/(app)/ch/[channel]/+page.svelte |  6 +++++-
 ui/styles/messages.css                    |  4 ++++
 ui/styles/variables.css                   |  1 +
 7 files changed, 45 insertions(+), 5 deletions(-)

(limited to 'ui/routes/(app)/ch/[channel]/+page.svelte')

diff --git a/ui/lib/components/Message.svelte b/ui/lib/components/Message.svelte
index edd9d79..ea90414 100644
--- a/ui/lib/components/Message.svelte
+++ b/ui/lib/components/Message.svelte
@@ -20,10 +20,17 @@
   }
 </script>
 
-<div class="message" class:delete-armed={deleteArmed} role="article" data-at={at} {onmouseleave}>
+<div
+  class="message"
+  class:delete-armed={deleteArmed}
+  class:unsent={id === null}
+  role="article"
+  data-at={at}
+  {onmouseleave}
+>
   <div class="handle">
     {atFormatted}
-    {#if editable}
+    {#if editable && id}
       <button onclick={ondelete}>&#x1F5D1;&#xFE0F;</button>
     {/if}
   </div>
diff --git a/ui/lib/outbox.svelte.js b/ui/lib/outbox.svelte.js
index fd7fdba..0e4cf29 100644
--- a/ui/lib/outbox.svelte.js
+++ b/ui/lib/outbox.svelte.js
@@ -1,3 +1,6 @@
+import { DateTime } from 'luxon';
+import * as msg from './state/remote/messages.svelte.js';
+
 import * as api from './apiServer.js';
 import * as md from './markdown.js';
 
@@ -5,9 +8,21 @@ class PostToChannel {
   constructor(channel, body) {
     this.channel = channel;
     this.body = body;
+    this.at = DateTime.now();
     this.renderedBody = md.render(body);
   }
 
+  toSkeleton(sender) {
+    return {
+      id: null,
+      at: this.at,
+      channel: this.channel,
+      sender,
+      body: this.body,
+      renderedBody: this.renderedBody
+    };
+  }
+
   async send() {
     return await api.retry(() => api.postToChannel(this.channel, this.body));
   }
@@ -35,6 +50,7 @@ class CreateChannel {
 
 export class Outbox {
   pending = $state([]);
+  messages = $derived(this.pending.filter((operation) => operation instanceof PostToChannel));
 
   static empty() {
     return new Outbox([]);
diff --git a/ui/lib/runs.js b/ui/lib/runs.js
index f4e90be..e3d4c20 100644
--- a/ui/lib/runs.js
+++ b/ui/lib/runs.js
@@ -21,5 +21,13 @@ function runKey(message) {
 }
 
 function continueRun([lastSender, lastAt], [newSender, newAt]) {
-  return lastSender === newSender && newAt - lastAt < RUN_COALESCE_MAX_INTERVAL;
+  const { id: lastId, name: lastName } = lastSender;
+  const { id: newId, name: newName } = newSender;
+  if (lastId !== newId) {
+    return false;
+  }
+  if (lastName !== newName) {
+    return false;
+  }
+  return newAt - lastAt < RUN_COALESCE_MAX_INTERVAL;
 }
diff --git a/ui/lib/state/remote/messages.svelte.js b/ui/lib/state/remote/messages.svelte.js
index 576a74e..c6d31f0 100644
--- a/ui/lib/state/remote/messages.svelte.js
+++ b/ui/lib/state/remote/messages.svelte.js
@@ -1,7 +1,7 @@
 import { DateTime } from 'luxon';
 import { render } from '$lib/markdown.js';
 
-class Message {
+export class Message {
   static boot({ id, at, channel, sender, body }) {
     return new Message({
       id,
diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte
index ccf455c..50b6a7d 100644
--- a/ui/routes/(app)/ch/[channel]/+page.svelte
+++ b/ui/routes/(app)/ch/[channel]/+page.svelte
@@ -12,7 +12,11 @@
 
   const channel = $derived(page.params.channel);
   const messages = $derived(session.messages.filter((message) => message.channel === channel));
-  const messageRuns = $derived(runs(messages, session.currentUser));
+  const unsent = $derived(outbox.messages.filter((message) => message.channel === channel));
+  const unsentSkeletons = $derived(
+    unsent.map((message) => message.toSkeleton($state.snapshot(session.currentUser)))
+  );
+  const messageRuns = $derived(runs(messages.concat(unsentSkeletons), session.currentUser));
 
   function inView(parentElement, element) {
     const parRect = parentElement.getBoundingClientRect();
diff --git a/ui/styles/messages.css b/ui/styles/messages.css
index 5890c1a..67a9517 100644
--- a/ui/styles/messages.css
+++ b/ui/styles/messages.css
@@ -39,6 +39,10 @@
   position: relative;
 }
 
+.message.unsent {
+  color: var(--colour-message-run-unsent-text);
+}
+
 .message.delete-armed,
 .message.delete-armed:hover {
   background-color: var(--colour-warn);
diff --git a/ui/styles/variables.css b/ui/styles/variables.css
index c43832c..01efc19 100644
--- a/ui/styles/variables.css
+++ b/ui/styles/variables.css
@@ -56,6 +56,7 @@
   );
   --colour-message-run-self-text: var(--dark-text);
   --colour-message-run-other-text: var(--dark-text);
+  --colour-message-run-unsent-text: var(--light-text);
 
   --colour-message-run-username-bg: color-mix(in srgb, var(--colour-base) 70%, white);
   --colour-message-run-username-border: color-mix(in srgb, var(--colour-base) 50%, black);
-- 
cgit v1.2.3


From a01b516844c2a89af9446864b3eccccdd5afb9dc Mon Sep 17 00:00:00 2001
From: Owen Jacobson <owen@grimoire.ca>
Date: Tue, 6 May 2025 01:28:28 -0400
Subject: Render messages as ghosts when there's a pending delete, too.

---
 ui/lib/components/Message.svelte          | 20 ++++++++++++++++----
 ui/lib/outbox.svelte.js                   |  1 +
 ui/routes/(app)/ch/[channel]/+page.svelte | 11 ++++++++++-
 ui/styles/messages.css                    |  3 ++-
 4 files changed, 29 insertions(+), 6 deletions(-)

(limited to 'ui/routes/(app)/ch/[channel]/+page.svelte')

diff --git a/ui/lib/components/Message.svelte b/ui/lib/components/Message.svelte
index ea90414..5d15d17 100644
--- a/ui/lib/components/Message.svelte
+++ b/ui/lib/components/Message.svelte
@@ -1,7 +1,15 @@
 <script>
   import { DateTime } from 'luxon';
 
-  let { id, at, body, renderedBody, editable = false, deleteMessage = async (id) => {} } = $props();
+  let {
+    class: cssClass,
+    id,
+    at,
+    body,
+    renderedBody,
+    editable = false,
+    deleteMessage = async (id) => {}
+  } = $props();
   let deleteArmed = $state(false);
   let atFormatted = $derived(at.toLocaleString(DateTime.DATETIME_SHORT));
 
@@ -21,9 +29,13 @@
 </script>
 
 <div
-  class="message"
-  class:delete-armed={deleteArmed}
-  class:unsent={id === null}
+  class={[
+    'message',
+    {
+      ['delete-armed']: deleteArmed
+    },
+    cssClass
+  ]}
   role="article"
   data-at={at}
   {onmouseleave}
diff --git a/ui/lib/outbox.svelte.js b/ui/lib/outbox.svelte.js
index 0e4cf29..472c58b 100644
--- a/ui/lib/outbox.svelte.js
+++ b/ui/lib/outbox.svelte.js
@@ -51,6 +51,7 @@ class CreateChannel {
 export class Outbox {
   pending = $state([]);
   messages = $derived(this.pending.filter((operation) => operation instanceof PostToChannel));
+  deleted = $derived(this.pending.filter((operation) => operation instanceof DeleteMessage));
 
   static empty() {
     return new Outbox([]);
diff --git a/ui/routes/(app)/ch/[channel]/+page.svelte b/ui/routes/(app)/ch/[channel]/+page.svelte
index 50b6a7d..33a9bdf 100644
--- a/ui/routes/(app)/ch/[channel]/+page.svelte
+++ b/ui/routes/(app)/ch/[channel]/+page.svelte
@@ -13,6 +13,7 @@
   const channel = $derived(page.params.channel);
   const messages = $derived(session.messages.filter((message) => message.channel === channel));
   const unsent = $derived(outbox.messages.filter((message) => message.channel === channel));
+  const deleted = $derived(outbox.deleted.map((message) => message.messageId));
   const unsentSkeletons = $derived(
     unsent.map((message) => message.toSkeleton($state.snapshot(session.currentUser)))
   );
@@ -95,7 +96,15 @@
       }}
     >
       {#each messages as message}
-        <Message {...message} editable={ownMessage} {deleteMessage} />
+        <Message
+          {...message}
+          editable={ownMessage}
+          {deleteMessage}
+          class={{
+            unsent: !message.id,
+            deleted: deleted.includes(message.id)
+          }}
+        />
       {/each}
     </MessageRun>
   {/each}
diff --git a/ui/styles/messages.css b/ui/styles/messages.css
index 67a9517..30f6db9 100644
--- a/ui/styles/messages.css
+++ b/ui/styles/messages.css
@@ -39,7 +39,8 @@
   position: relative;
 }
 
-.message.unsent {
+.message.unsent,
+.message.deleted {
   color: var(--colour-message-run-unsent-text);
 }
 
-- 
cgit v1.2.3

